//This file is a part of StoragePlug for Minecraft.
//http://code.google.com/p/minecraft-storageplug/
//StoragePlug is Copyright (C) 2011 Christopher Smith
//Released under the MIT license:
//
//Permission is hereby granted, free of charge, to any person obtaining a copy
//of this software and associated documentation files (the "Software"), to deal
//in the Software without restriction, including without limitation the rights 
//to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
//copies of the Software, and to permit persons to whom the Software is
//furnished to do so, subject to the following conditions:
//
//The above copyright notice and this permission notice shall be included in
//all copies or substantial portions of the Software.
//
//THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
//IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
//FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
//AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
//LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
//FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
//DEALINGS IN THE SOFTWARE.
//
//Related to Minecraft Copyright (C) 2009-2011 Mojang http://www.minecraft.net/

package net.minecraft.storageplug.jdbc;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.sql.Connection;
import java.sql.Driver;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.AbstractMap.SimpleEntry;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Properties;
import java.util.concurrent.ConcurrentHashMap;
import javax.naming.ConfigurationException;
import net.minecraft.storageplug.DeflateStreamStorageAdapter;
import net.minecraft.storageplug.Key;
import net.minecraft.storageplug.StorageProvider;
import net.minecraft.storageplug.StreamStorageAdapter;
import net.minecraft.storageplug.StreamStorageProvider;
import net.minecraft.storageplug.StreamStorageProvider.ReadOperation;
import net.minecraft.storageplug.StreamStorageProvider.WriteOperation;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;

public class JdbcStorageProvider
    implements StreamStorageProvider
{
    private class Link
    {
        public Connection conn;
		private final PreparedStatement readWorldInfo;
        private final PreparedStatement deleteWorldInfo;
		private final PreparedStatement writeWorldInfo;
		private final PreparedStatement readItemData;
		private final PreparedStatement deleteItemData;
		private final PreparedStatement writeItemData;
		private final PreparedStatement readChunk;
		private final PreparedStatement deleteChunk;
		private final PreparedStatement writeChunk;
		private final PreparedStatement readPlayer;
		private final PreparedStatement deletePlayer;
		private final PreparedStatement writePlayer;
        
        private boolean updatesWorldInfo;
        private boolean updatesItemData;
        private boolean updatesPlayer;
        private boolean updatesChunk;
        
        public Link(Driver driver, String url, Properties info)
            throws Exception
        {
            conn = driver.connect(url, info);
            conn.setAutoCommit(false);
            
			readWorldInfo = conn.prepareStatement("SELECT nbt FROM world WHERE worldid = ?");
            deleteWorldInfo = conn.prepareStatement("DELETE FROM world WHERE worldid = ?");
			writeWorldInfo = conn.prepareStatement("INSERT INTO world(nbt, worldid, name) VALUES(?, ?, ?)");
            
			readItemData = conn.prepareStatement("SELECT nbt FROM itemdata WHERE worldid = ? AND name = ?");
			deleteItemData = conn.prepareStatement("DELETE FROM itemdata WHERE worldid = ? AND name = ?");
			writeItemData = conn.prepareStatement("INSERT INTO itemdata(nbt, worldid, name) VALUES(?,?,?)");
            
			readChunk = conn.prepareStatement("SELECT nbt FROM chunk WHERE worldid = ? AND dim = ? AND x = ? AND z = ?");
            deleteChunk = conn.prepareStatement("DELETE FROM chunk WHERE worldid = ? AND dim = ? AND x = ? AND z = ?");
			writeChunk = conn.prepareStatement("INSERT INTO chunk(nbt, worldid, dim, x, z) VALUES(?,?,?,?,?)");
            
			readPlayer = conn.prepareStatement("SELECT nbt FROM player WHERE worldid = ? AND name = ?");
            deletePlayer = conn.prepareStatement("DELETE FROM player WHERE worldid = ? AND name = ?");
			writePlayer = conn.prepareStatement("INSERT INTO player(nbt, worldid, name) VALUES(?,?,?)");
        }
        
        public void dispose()
        {
            try
            {
                conn.close();
            }
            catch(Exception e)
            {
                //don't want to crash during cleanups
                e.printStackTrace();
            }
        }
                    
        private void readToStream(PreparedStatement stmt, ReadOperation op)
            throws Exception
        {
            try
            {
                ResultSet rs = stmt.executeQuery();

                if(rs.next())
                {
                    op.read(rs.getBinaryStream(1));
                }
            }
            finally
            {
                //cleanup any result sets
                stmt.getMoreResults(Statement.CLOSE_ALL_RESULTS);
            }
        }

        private void writeFromStream(PreparedStatement stmt, WriteOperation op)
            throws Exception
        {
            try
            {
                ByteArrayOutputStream os = new ByteArrayOutputStream();
                op.write(os);

                stmt.setBytes(1, os.toByteArray());

                stmt.addBatch();
            }
            finally
            {
                //cleanup any result sets
                stmt.getMoreResults(Statement.CLOSE_ALL_RESULTS);
            }
        }
        

        public void readWorldInfo(Key.WorldInfo key, ReadOperation op)
            throws Exception
        {
            readWorldInfo.setInt(1, worldid);
            readToStream(readWorldInfo, op);
        }
        public void readPlayer(Key.Player key, ReadOperation op)
            throws Exception
        {
            readPlayer.setInt(1, worldid);
            readPlayer.setString(2, key.name);
            readToStream(readPlayer, op);
        }
        public void readItemData(Key.ItemData key, ReadOperation op)
            throws Exception
        {
            readItemData.setInt(1, worldid);
            readItemData.setString(2, key.name);
            readToStream(readItemData, op);
        }
        public void readChunk(Key.Chunk key, ReadOperation op)
            throws Exception
        {
            readChunk.setInt(1, worldid);
            readChunk.setInt(2, key.dimension);
            readChunk.setInt(3, key.x);
            readChunk.setInt(4, key.z);
            readToStream(readChunk, op);
        }
        public void readChunkBatch(Map<Key.Chunk, ReadOperation> ops)
            throws Exception
        {
            StringBuilder sb = new StringBuilder(2048);
            sb.append("SELECT dim, x, z, nbt FROM chunk WHERE worldid=");
            sb.append(worldid);
            sb.append(" AND (1=0 ");
            for(Key.Chunk key : ops.keySet())
            {
                sb.append("OR(");
                    sb.append("dim="); sb.append(key.dimension);
                    sb.append(" AND x="); sb.append(key.x);
                    sb.append(" AND z="); sb.append(key.z);
                sb.append(')');
            }
            sb.append(')');
            
            Statement stmt = null;
            ResultSet rs = null;
            try
            {
                stmt = conn.createStatement();
                rs = stmt.executeQuery(sb.toString());

                while(rs.next())
                {
                    ReadOperation op = ops.get(new Key.Chunk(rs.getInt(1), rs.getInt(2), rs.getInt(3)));
                    op.read(rs.getBinaryStream(4));
                }
            }
            finally
            {
                if(rs != null)
                    rs.close();
                if(stmt != null)
                    stmt.close();
            }
        }
        public void writeWorldInfo(Key.WorldInfo key, WriteOperation op)
            throws Exception
        {
            deleteWorldInfo.setInt(1, worldid);
            deleteWorldInfo.addBatch();
            
            writeWorldInfo.setInt(2, worldid);
            writeWorldInfo.setString(3,worldname);
            writeFromStream(writeWorldInfo, op);
            
            updatesWorldInfo = true;
        }
        public void writePlayer(Key.Player key, WriteOperation op)
            throws Exception
        {
            deletePlayer.setInt(1, worldid);
            deletePlayer.setString(2, key.name);
            deletePlayer.addBatch();
            
            writePlayer.setInt(2, worldid);
            writePlayer.setString(3, key.name);
            writeFromStream(writePlayer, op);
            
            updatesPlayer = true;
        }
        public void writeItemData(Key.ItemData key, WriteOperation op)
            throws Exception
        {
            deleteItemData.setInt(1, worldid);
            deleteItemData.setString(2, key.name);
            deleteItemData.addBatch();
            
            writeItemData.setInt(2, worldid);
            writeItemData.setString(3, key.name);
            writeFromStream(writeItemData, op);
            
            updatesItemData = true;
        }
        public void writeChunk(Key.Chunk key, WriteOperation op)
            throws Exception
        {
            deleteChunk.setInt(1, worldid);
            deleteChunk.setInt(2, key.dimension);
            deleteChunk.setInt(3, key.x);
            deleteChunk.setInt(4, key.z);
            deleteChunk.addBatch();
            
            writeChunk.setInt(2, worldid);
            writeChunk.setInt(3, key.dimension);
            writeChunk.setInt(4, key.x);
            writeChunk.setInt(5, key.z);
            writeFromStream(writeChunk, op);
            
            updatesChunk = true;
        }
        
        public void commit()
            throws Exception
        {
            if(updatesWorldInfo)
            {
                deleteWorldInfo.executeBatch();
                writeWorldInfo.executeBatch();
            }
            if(updatesPlayer)
            {
                deletePlayer.executeBatch();
                writePlayer.executeBatch();
            }
            if(updatesItemData)
            {
                deleteItemData.executeBatch();
                writeItemData.executeBatch();
            }
            if(updatesChunk)
            {
                deleteChunk.executeBatch();
                writeChunk.executeBatch();
            }
            
            conn.commit();
            updatesWorldInfo = updatesPlayer = updatesItemData = updatesChunk = false;
        }
        
        public void rollback()
            throws Exception
        {
            conn.rollback();
            updatesWorldInfo = updatesPlayer = updatesItemData = updatesChunk = false;
        }
    }
    
    private final Driver driver;
    private final String dbUrl;
    private final Properties dbInfo;

    private final ConcurrentHashMap<Thread, Link> pool;
    
    private final int worldid;
    private final String worldname;
    
    public JdbcStorageProvider(Driver driver, String dbUrl, Properties dbInfo, String worldname, int worldid)
    {
        this.driver = driver;
        this.dbUrl = dbUrl;
        this.dbInfo = dbInfo;
        this.worldid = worldid;
        this.worldname = worldname;
        
        pool = new ConcurrentHashMap<Thread, Link>(4);
    }
    
    private static StorageProvider configFailure(File storagedir, String message)
        throws Exception
    {
        throw new ConfigurationException("JdbcStorageProvider: " + storagedir.getName() + " " + message);
    }

	public static StorageProvider createInstance(File storagedir, Element config, ClassLoader loader)
        throws Exception
	{
        Properties dbinfo = new Properties();
        
        NodeList urls = config.getElementsByTagName("URL");
        if(urls == null)
            return configFailure(storagedir, "No URL specified.");
        if(urls.getLength() != 1)
            return configFailure(storagedir, "Exactly one URL must be specified.");
        String url = urls.item(0).getTextContent(); //text from single element
          
        NodeList driverclasses = config.getElementsByTagName("DriverClass");
        if(driverclasses == null)
            return configFailure(storagedir, "No DriverClass specified.");
        if(driverclasses.getLength() != 1)
            return configFailure(storagedir, "Exactly one DriverClass must be specified.");
        String driverclass = driverclasses.item(0).getTextContent(); //text from single element
        
        NodeList worldnames = config.getElementsByTagName("WorldName");
        String worldname;
        if(worldnames == null || worldnames.getLength() == 0)
            worldname = storagedir.getName();
        else if(worldnames.getLength() > 1)
            return configFailure(storagedir, "There can be at most one WorldName specified (it defaults to the directory name).");
        else
             worldname = worldnames.item(0).getTextContent(); //text from single element

        Driver driver = null;
        driver = (Driver) loader.loadClass(driverclass).newInstance();
        
        Connection conn = null;
        PreparedStatement stmt = null;
        ResultSet rs = null;
        int worldid;
        try
        {
            conn = driver.connect(url, dbinfo);
            conn.setAutoCommit(false);

            stmt = conn.prepareStatement("SELECT worldid FROM world WHERE name = ?");
            stmt.setString(1, worldname);
            rs = stmt.executeQuery();
            
            if(rs.next())
                worldid = rs.getInt("worldid");
            else
            {
                rs.close(); rs = null;
                stmt.close(); stmt = null;
                
                stmt = conn.prepareStatement("SELECT MAX(worldid) + 1 worldid FROM world");
                rs = stmt.executeQuery();
                rs.next();
                
                worldid = rs.getInt("worldid"); //will be zero if MAX(worldid)+1 was null
            }
        }
        finally
        {
            if(rs != null)
                rs.close();
            if(stmt != null)
                stmt.close();
            if(conn != null)
                conn.close();
        }
        
		return new StreamStorageAdapter(new DeflateStreamStorageAdapter(new JdbcStorageProvider(driver, url, dbinfo, worldname, worldid)));
	}
    
    private Link getLink()
        throws Exception
    {
        Thread currentThread = Thread.currentThread();
        Link link = null;
        
        if(pool.containsKey(currentThread))
        {
            link = pool.get(currentThread);
        }
        else
        {
            link = new Link(driver, dbUrl, dbInfo);
            pool.put(currentThread, link);
        }
        
        return link;
    }

	@Override
	public void flush()
	{
		//no interface to flush, do nothing
	}

	@Override
	public void dispose()
	{
		for(Link l : pool.values())
			l.dispose();
	}
    
    private void readDataStream(ReadOperation op, Link link)
        throws Exception
    {
        Key key = op.key();
        
        if(key instanceof Key.WorldInfo)
            link.readWorldInfo((Key.WorldInfo)key, op);
        else if(key instanceof Key.Player)
            link.readPlayer((Key.Player)key, op);
        else if(key instanceof Key.ItemData)
            link.readItemData((Key.ItemData)key, op);
        else if(key instanceof Key.Chunk)
            link.readChunk((Key.Chunk)key, op);
    }
    
    private void writeDataStream(WriteOperation op, Link link)
        throws Exception
    {
        Key key = op.key();
        
        if(key instanceof Key.WorldInfo)
            link.writeWorldInfo((Key.WorldInfo)key, op);
        else if(key instanceof Key.Player)
            link.writePlayer((Key.Player)key, op);
        else if(key instanceof Key.ItemData)
            link.writeItemData((Key.ItemData)key, op);
        else if(key instanceof Key.Chunk)
            link.writeChunk((Key.Chunk)key, op);
    }
           
    @Override
    public void readDataStream(ReadOperation op)
        throws Exception
    {
        Link link = getLink();
        boolean successful = false;
        
        try
        {
            readDataStream(op, link);
            successful = true;
        }
        finally
        {
            if(successful)
                link.commit();
            else
                link.rollback();
        }     
    }

    @Override
    public void writeDataStream(WriteOperation op)
        throws Exception
    {
        Link link = getLink();
        boolean successful = false;
        
        try
        {
            writeDataStream(op, link);
            successful = true;
        }
        finally
        {
            if(successful)
                link.commit();
            else
                link.rollback();
        }  
    }

    @Override
    public void readDataStreamBatch(Collection<? extends ReadOperation> list)
        throws Exception
    {
        Link link = getLink();
        boolean successful = false;
        int chunkKeyCount = 0;

        //see how many chunks there are
        for(ReadOperation op : list)
            if(op.key() instanceof Key.Chunk)
                chunkKeyCount++;
        
        HashMap<Key.Chunk, ReadOperation> chunkOps = new HashMap<Key.Chunk, ReadOperation>(chunkKeyCount);
        
        try
        {
            for(ReadOperation op : list)
                if(op.key() instanceof Key.Chunk)               
                    chunkOps.put((Key.Chunk)op.key(), op); //put chunk reads in special stream path
                else
                    readDataStream(op, link);
            
            if(chunkKeyCount > 0)
                link.readChunkBatch(chunkOps);
            
            successful = true;
        }
        finally
        {
            if(successful)
                link.commit();
            else
                link.rollback();
        }
    }

    @Override
    public void writeDataStreamBatch(Collection<? extends WriteOperation> list)
        throws Exception
    {
        Link link = getLink();
        boolean successful = false;
        
        try
        {
            for(WriteOperation op : list)
                writeDataStream(op, link);
            successful = true;
        }
        finally
        {
            if(successful)
                link.commit();
            else
                link.rollback();
        }
    }

    @Override
    public Collection<? extends Key> iterKeys(Class keytype)
        throws Exception
    {
        Link link = getLink();
        Connection conn = link.conn;
        PreparedStatement stmt = null;
        ResultSet rs = null;
        
        try
        {
            if(keytype.equals(Key.WorldInfo.class))
            {
                List<Key.WorldInfo> list = new ArrayList<Key.WorldInfo>(1);
                list.add(new Key.WorldInfo());
                return list;
            }
            else if (keytype.equals(Key.Player.class))
            {
                List<Key.Player> list = new ArrayList<Key.Player>(1);

                stmt = conn.prepareStatement("SELECT name FROM player WHERE worldid = ?");                
                stmt.setInt(1, worldid);
                rs = stmt.executeQuery();

                while(rs.next())
                    list.add(new Key.Player(rs.getString(1)));

                return list;
            }
            else if (keytype.equals(Key.ItemData.class))
            {
                List<Key.ItemData> list = new ArrayList<Key.ItemData>(1);

                stmt = conn.prepareStatement("SELECT name FROM itemdata WHERE worldid = ?");
                stmt.setInt(1, worldid);
                rs = stmt.executeQuery();

                while(rs.next())
                    list.add(new Key.ItemData(rs.getString(1)));

                return list;
            }
            else if (keytype.equals(Key.Chunk.class))
            {
                List<Key.Chunk> list = new ArrayList<Key.Chunk>(1);

                stmt = conn.prepareStatement("SELECT dim, x, z FROM chunk WHERE worldid = ?");
                stmt.setInt(1, worldid);
                rs = stmt.executeQuery();

                while(rs.next())
                    list.add(new Key.Chunk(rs.getInt(1), rs.getInt(2), rs.getInt(3)));

                return list;
            }
        }
        finally
        {
            if(rs != null)
                rs.close();
            
            if(stmt != null)
                stmt.close();
        }

        return null;
    }

    @Override
    public int nextUniqueID(String key)
        throws Exception
    {
		Link link = getLink();
		Connection conn = link.conn;
		
		PreparedStatement stmt = null;
        ResultSet rs = null;
        boolean successful = false;
		
		try
		{
			stmt = conn.prepareStatement("SELECT lastval FROM uniqueid WHERE worldid = ? AND name = ?");
			stmt.setInt(1, worldid);
            stmt.setString(2, key);
            rs = stmt.executeQuery();
			
            //find out if there's already a record for this key
			if(rs.next())
			{
				int value = rs.getInt(1) + 1;
				
                rs.close(); rs = null;
				stmt.close(); stmt = null;
				
				stmt = conn.prepareStatement("UPDATE uniqueid SET lastval = ? WHERE worldid = ? AND name = ?");
                stmt.setInt(1, value);
                stmt.setInt(2, worldid);
                stmt.setString(3, key);
                stmt.execute();
				
				return value;
			}
			else
			{
                rs.close(); rs = null;
				stmt.close(); stmt = null;

                stmt = conn.prepareStatement("INSERT INTO uniqueid(worldid, name, lastval) VALUES(?, ?, ?)");
                stmt.setInt(1, worldid);
                stmt.setString(2, key);
                stmt.setInt(3, 0); //start at 0
                stmt.execute();
                
				return 0;
			}
		}
		finally
		{
            if(rs != null)
                rs.close();
			if(stmt != null)
				stmt.close();
            
            if(successful)
                conn.commit();
            else
                conn.rollback();
		}	
    }

    @Override
    public Collection<? extends Map.Entry<String, Integer>> readUniqueIDs()
        throws Exception
    {
		Link link = getLink();
		Connection conn = link.conn;
		PreparedStatement stmt = null;
        ResultSet rs = null;
		ArrayList<SimpleEntry<String, Integer>> map = new ArrayList<SimpleEntry<String, Integer>>(64);
		
		try
		{
			stmt = conn.prepareStatement("SELECT name, lastval FROM uniqueid WHERE worldid = ?");
            stmt.setInt(1, worldid);
            rs = stmt.executeQuery();
		
			while(rs.next())
				map.add(new SimpleEntry<String, Integer>(rs.getNString(1), rs.getInt(1)));
		}
		finally
		{
            if(rs != null)
                rs.close();
            
			if(stmt != null)
				stmt.close();
            
            conn.commit(); //release locks
		}
        		
		return map;
    }
    
    @Override
    public void writeUniqueIDs(Collection<? extends Map.Entry<String, Integer>> map)
        throws Exception
    {
		Link link = getLink();
        Connection conn = link.conn;
		PreparedStatement stmt = null;
        ResultSet rs = null;
        boolean successful = false;
		
		try
		{		
            stmt = conn.prepareStatement("DELETE FROM uniqueid WHERE worldid = ?");
            stmt.setInt(1, worldid);
            stmt.execute();
            
            stmt.close(); stmt = null;
            
			stmt = conn.prepareStatement("INSERT INTO uniqueid(worldid, name, lastval) VALUES(?, ?,?)");
            stmt.setInt(1, worldid);
		
			for(Entry<String, Integer> i : map)
			{
				stmt.setString(2, i.getKey());
				stmt.setInt(3, i.getValue());
				
				stmt.execute();
			}
		}
		finally
		{
            if(rs != null)
                rs.close();
			if(stmt != null)
				stmt.close();
            
            if(successful)
                conn.commit();
            else
                conn.rollback();
		}
    }
}
